package org.unidata.mdm.rest.v1.data.service.relations;

import static org.unidata.mdm.meta.type.search.RelationHeaderField.FIELD_DIRECTION_FROM;
import static org.unidata.mdm.meta.type.search.RelationHeaderField.FIELD_FROM;
import static org.unidata.mdm.meta.type.search.RelationHeaderField.FIELD_FROM_ETALON_ID;
import static org.unidata.mdm.meta.type.search.RelationHeaderField.FIELD_RELATION_NAME;
import static org.unidata.mdm.system.util.ConvertUtils.localDateTime2Date;

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.Date;
import java.util.EnumSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;

import javax.ws.rs.DELETE;
import javax.ws.rs.DefaultValue;
import javax.ws.rs.GET;
import javax.ws.rs.HttpMethod;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.QueryParam;
import javax.ws.rs.core.Response;

import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.ObjectUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.tuple.Pair;
import org.apache.commons.lang3.tuple.Triple;
import org.springframework.beans.factory.annotation.Autowired;
import org.unidata.mdm.core.type.model.RelationElement;
import org.unidata.mdm.core.type.timeline.TimeInterval;
import org.unidata.mdm.core.type.timeline.Timeline;
import org.unidata.mdm.core.util.SecurityUtils;
import org.unidata.mdm.data.context.DeleteRelationRequestContext;
import org.unidata.mdm.data.context.GetRelationRequestContext;
import org.unidata.mdm.data.context.GetRelationTimelineRequestContext;
import org.unidata.mdm.data.context.GetRelationsTimelineRequestContext;
import org.unidata.mdm.data.context.UpsertRelationRequestContext;
import org.unidata.mdm.data.context.UpsertRelationsRequestContext;
import org.unidata.mdm.data.dto.DeleteRelationDTO;
import org.unidata.mdm.data.dto.GetRelationDTO;
import org.unidata.mdm.data.dto.GetTimelineResult;
import org.unidata.mdm.data.dto.GetTimelinesResult;
import org.unidata.mdm.data.dto.RelationStateDTO;
import org.unidata.mdm.data.dto.UpsertRelationDTO;
import org.unidata.mdm.data.dto.UpsertRelationsDTO;
import org.unidata.mdm.data.service.DataRelationsService;
import org.unidata.mdm.data.service.impl.CommonRelationsComponent;
import org.unidata.mdm.data.type.data.EtalonRelation;
import org.unidata.mdm.data.type.data.OriginRelation;
import org.unidata.mdm.data.type.keys.RelationKeys;
import org.unidata.mdm.meta.configuration.Descriptors;
import org.unidata.mdm.meta.type.search.EntityIndexType;
import org.unidata.mdm.meta.type.search.RelationHeaderField;
import org.unidata.mdm.rest.system.ro.DetailedErrorResponseRO;
import org.unidata.mdm.rest.system.ro.details.ResultDetailsRO;
import org.unidata.mdm.rest.system.util.RestConstants;
import org.unidata.mdm.rest.v1.data.converter.DataRecordEtalonConverter;
import org.unidata.mdm.rest.v1.data.converter.RelationKeysConverter;
import org.unidata.mdm.rest.v1.data.converter.RelationToEtalonConverter;
import org.unidata.mdm.rest.v1.data.converter.TimelineToTimelineROConverter;
import org.unidata.mdm.rest.v1.data.ro.BaseRelationRO;
import org.unidata.mdm.rest.v1.data.ro.TimelineRO;
import org.unidata.mdm.rest.v1.data.ro.keys.RecordExternalIdRO;
import org.unidata.mdm.rest.v1.data.ro.records.DataRecordRO;
import org.unidata.mdm.rest.v1.data.ro.records.EtalonRelationToRO;
import org.unidata.mdm.rest.v1.data.ro.relations.DeleteRelationRequestRO;
import org.unidata.mdm.rest.v1.data.ro.relations.DeleteRelationResultRO;
import org.unidata.mdm.rest.v1.data.ro.relations.GetRelationRequestRO;
import org.unidata.mdm.rest.v1.data.ro.relations.GetRelationResultRO;
import org.unidata.mdm.rest.v1.data.ro.relations.GetRelationTimelineRequestRO;
import org.unidata.mdm.rest.v1.data.ro.relations.GetRelationTimelineResultRO;
import org.unidata.mdm.rest.v1.data.ro.relations.GetRelationTimelinesRequestRO;
import org.unidata.mdm.rest.v1.data.ro.relations.GetRelationTimelinesResultRO;
import org.unidata.mdm.rest.v1.data.ro.relations.GetRelationsResultRO;
import org.unidata.mdm.rest.v1.data.ro.relations.RecordRelationRO;
import org.unidata.mdm.rest.v1.data.ro.relations.UpsertRelationsRequestRO;
import org.unidata.mdm.rest.v1.data.ro.relations.UpsertRelationsResultRO;
import org.unidata.mdm.rest.v1.data.service.AbstractDataRestService;
import org.unidata.mdm.rest.v1.data.service.search.SearchResultHitModifier;
import org.unidata.mdm.rest.v1.search.converter.SearchResultToRestSearchResultConverter;
import org.unidata.mdm.rest.v1.search.ro.SearchResultRO;
import org.unidata.mdm.rest.v1.search.type.result.SearchResultProcessingElements;
import org.unidata.mdm.search.context.ComplexSearchRequestContext;
import org.unidata.mdm.search.context.SearchRequestContext;
import org.unidata.mdm.search.dto.ComplexSearchResultDTO;
import org.unidata.mdm.search.dto.SearchResultDTO;
import org.unidata.mdm.search.service.SearchService;
import org.unidata.mdm.search.type.form.FieldsGroup;
import org.unidata.mdm.search.type.form.FormField;
import org.unidata.mdm.search.type.query.SearchQuery;
import org.unidata.mdm.system.type.runtime.MeasurementContextName;
import org.unidata.mdm.system.type.runtime.MeasurementPoint;
import org.unidata.mdm.system.util.ConvertUtils;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.enums.ParameterIn;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;

/**
 * Controller that implements operations with relations
 *
 * @author Alexandr Serov
 * @since 14.10.2020
 **/
@Path("/relations")
@Tag(name = "relations")
public class DataRelationsRestService extends AbstractDataRestService {

    private static final String RELATIONS_TAGS = "relations";

    /**
     * Relation.
     */
    public static final String PATH_PARAM_RELATION = "relation";

    /**
     * Relations search.
     */
    public static final String PATH_PARAM_SEARCH = "search";

    public static final String RELATION_ETALON_ID_PARAM = "relationEtalonId";

    public static final String ETALON_ID_PARAM = "etalonId";

    public static final String RELATION_NAME_PARAM = "relationName";

    /**
     * Data relation facade.
     */
    @Autowired
    private DataRelationsService dataRelationsService;

    @Autowired
    private CommonRelationsComponent commonRelationsComponent;

    /**
     * Search service.
     */
    @Autowired
    private SearchService searchService;

    /**
     * Modify search result for ui presentation
     */
    @Autowired
    private SearchResultHitModifier searchResultHitModifier;

    /**
     * Gets etalon relation objects along with their time lines by record etalon ID.
     * @param etalonId record's etalon ID
     * @param name name of the relation
     * @param timestamps records validity period boundary
     * @return response array of time line objects
     */
    @GET
    @Path("/" + RestConstants.PATH_PARAM_RELATION_BULK + "/{" + RestConstants.DATA_PARAM_ID + "}/{" + RestConstants.DATA_PARAM_NAME + "}")
    @Operation(
        description = "Gets relations of a record (as seen from the left|outgoing side).",
        method = HttpMethod.GET,
        tags = { RELATIONS_TAGS },
        responses = {
            @ApiResponse(content = @Content(schema = @Schema(implementation = GetRelationsResultRO.class)), responseCode = "200"),
            @ApiResponse(content = @Content(schema = @Schema(implementation = DetailedErrorResponseRO.class)), responseCode = "500")
        }
    )
    public Response getRelationsByRecordEtalonIdAndBoundary(
            @Parameter(description = "Record's etalon ID.", in = ParameterIn.PATH) @PathParam(RestConstants.DATA_PARAM_ID) String etalonId,
            @Parameter(description = "Relation's name.", in = ParameterIn.PATH) @PathParam(RestConstants.DATA_PARAM_NAME) String name,
            @Parameter(description = "The right (from) interval boundary.", in = ParameterIn.QUERY) @QueryParam(RestConstants.DATA_PARAM_FROM) @DefaultValue(StringUtils.EMPTY) String fromAsString,
            @Parameter(description = "The left (to) interval boundary.", in = ParameterIn.QUERY) @QueryParam(RestConstants.DATA_PARAM_TO) @DefaultValue(StringUtils.EMPTY) String toAsString,
            @Parameter(description = "Parent draft id. Optional.", in = ParameterIn.QUERY) @QueryParam(RestConstants.QUERY_PARAM_DRAFT_ID) @DefaultValue("0") long draftId,
            @Parameter(description = "Include inactive relations/intervals (false is default).", in = ParameterIn.QUERY) @QueryParam(RestConstants.DATA_PARAM_INCLUDE_INACTIVE) @DefaultValue("false") boolean includeInactive) {

        Date validFrom = StringUtils.isBlank(fromAsString) ? null : ConvertUtils.string2Date(fromAsString);
        Date validTo = StringUtils.isBlank(toAsString) ? null : ConvertUtils.string2Date(toAsString);


        List<EtalonRelationToRO> relations = new ArrayList<>();
        Date now = new Date();

        Map<String, List<Triple<String, Date, Date>>> displayNames
            = searchResultHitModifier.getDisplayNamesForRelationTo(name, etalonId, null, validFrom, validTo);

        GetRelationsTimelineRequestContext tlCtx = GetRelationsTimelineRequestContext.builder()
                .etalonKey(etalonId)
                .relationNames(name)
                .forDatesFrame(Pair.of(validFrom, validTo))
                .reduceReferences(true)
                .fetchData(true)
                .parentDraftId(draftId)
                .includeInactive(includeInactive)
                .build();

        GetTimelinesResult<OriginRelation> returned = commonRelationsComponent.loadTimelinesExt(tlCtx);
        for (GetTimelineResult<OriginRelation> timeline : returned.getTimelines(name)) {

            RelationKeys k = timeline.getTimeline().getKeys();
            if (k != null && !k.isActive() && !includeInactive) {
                continue;
            }

            for (TimeInterval<OriginRelation> timeInterval : timeline.getTimeline()) {

                if (!timeInterval.isActive() && !includeInactive) {
                    continue;
                }

                EtalonRelationToRO converted = null;
                if (timeInterval.hasCalculationResult()) {

                    EtalonRelation etalonRelation = timeInterval.getCalculationResult();
                    converted = RelationToEtalonConverter.to(etalonRelation);
                    if (converted != null) {
                        converted.setActive(timeInterval.isActive());
                        converted.setEtalonId(timeline.getTimeline().getKeys().getEtalonKey().getId());
                        converted.setDraftId(timeline.getDraftId());
                        converted.setEtalonDisplayNameTo(searchResultHitModifier.extractDisplayName(displayNames,
                                converted.getEtalonIdTo(),
                                ConvertUtils.localDateTime2Date(converted.getValidFrom()),
                                ConvertUtils.localDateTime2Date(converted.getValidTo()),
                                now));
                    }

                    relations.add(converted);
                }
            }
        }
//        } else {
//
//            if (def.isContainment()) {
//
//                // TODO: Fix
//                if (CollectionUtils.isEmpty(returnFields)){
//                    returnFields = new ArrayList<>(def.getRight().getMainDisplayableAttributes().keySet());
//                }
//
//                return Response.ok(new RestResponse<>(getRelationsToSideByTimelineAndFromEtalonId(etalonId,
//                        validFrom,
//                        validTo,
//                        def,
//                        returnFields)))
//                        .build();
//            } else {
//
//                if (CollectionUtils.isEmpty(returnFields)) {
//
//                    returnFields = def.getAttributes().values()
//                            .stream()
//                            .map(AttributeElement::getName)
//                            .collect(Collectors.toList());
//                }
//
//                SearchResultDTO relationsSearchResult = getRelationsByTimelineAndFromEtalonId(etalonId, validFrom, validTo, def, returnFields);
//                relations.addAll(RelationToEtalonConverter.to(relationsSearchResult, def));
//                relations.forEach(relation -> {
//                    EtalonRelationToRO relationTO = relation;
//                    relationTO.setEtalonDisplayNameTo(searchResultHitModifier.extractDisplayName(displayNames,
//                            relationTO.getEtalonIdTo(),
//                            ConvertUtils.localDateTime2Date(relationTO.getValidFrom()),
//                            ConvertUtils.localDateTime2Date(relationTO.getValidTo()),
//                            now));
//                });
//            }
//        }

        relations.sort(Comparator.comparing(BaseRelationRO::getCreateDate));
        return ok(new GetRelationsResultRO(Collections.singletonMap(name, relations)));
    }

    private SearchResultRO getRelationsToSideByTimelineAndFromEtalonId(String etalonId,
            Date validFrom,
            Date validTo,
            RelationElement relationDef,
            List<String> returnFields) {

        FormField fromEtalon = FormField.exact(FIELD_FROM_ETALON_ID, etalonId);
        FormField notFrom = FormField.notInRange(RelationHeaderField.FIELD_TO, null, validFrom);
        FormField notTo = FormField.notInRange(FIELD_FROM, validTo, null);
        FormField relName = FormField.exact(FIELD_RELATION_NAME, relationDef.getName());
        FormField direct = FormField.exact(FIELD_DIRECTION_FROM, false);

        List<String> searchFields = new ArrayList<>();
        searchFields.add(RelationHeaderField.FIELD_CREATED_AT.getName());
        searchFields.add(RelationHeaderField.FIELD_UPDATED_AT.getName());
        searchFields.addAll(returnFields);

        SearchRequestContext mainContext = SearchRequestContext.builder(EntityIndexType.RECORD, relationDef.getRight().getName(), SecurityUtils.getCurrentUserStorageId())
            .totalCount(true)
            .fetchAll(true)
            .count(1000)
            .returnFields(searchFields)
            .build();

        SearchRequestContext relationsContext = SearchRequestContext.builder(EntityIndexType.RELATION, relationDef.getRight().getName(), SecurityUtils.getCurrentUserStorageId())
            .query(SearchQuery.formQuery(FieldsGroup.and(fromEtalon, notFrom, notTo, relName, direct)))
            .returnFields(Arrays.asList(FIELD_FROM.getName(), RelationHeaderField.FIELD_TO.getName()))
            .count(1000)
            .build();

        ComplexSearchRequestContext getToSide = ComplexSearchRequestContext.hierarchical(mainContext, relationsContext);
        ComplexSearchResultDTO toSide = searchService.search(getToSide);
        searchResultHitModifier.modifySearchResult(toSide.getMain());
        return SearchResultToRestSearchResultConverter.convert(toSide.getMain());

    }

    private SearchResultDTO getRelationsByTimelineAndFromEtalonId(String etalonId,
           Date validFrom,
           Date validTo,
           RelationElement relationDef,
           List<String> returnFields) {        // restriction for get relations data

        FormField fromEtalon = FormField.exact(FIELD_FROM_ETALON_ID, etalonId);
        FormField notFrom = FormField.notInRange(RelationHeaderField.FIELD_TO, null, validFrom);
        FormField notTo = FormField.notInRange(FIELD_FROM, validTo, null);
        FormField relName = FormField.exact(FIELD_RELATION_NAME, relationDef.getName());
        FormField direct = FormField.exact(FIELD_DIRECTION_FROM, false);

        List<String> searchFields = new ArrayList<>();
        searchFields.add(FIELD_FROM.getName());
        searchFields.add(RelationHeaderField.FIELD_ETALON_ID.getName());
        searchFields.add(RelationHeaderField.FIELD_TO.getName());
        searchFields.add(RelationHeaderField.FIELD_TO_ETALON_ID.getName());
        searchFields.add(RelationHeaderField.FIELD_CREATED_AT.getName());
        searchFields.add(RelationHeaderField.FIELD_UPDATED_AT.getName());

        if (CollectionUtils.isNotEmpty(returnFields)) {
            searchFields.addAll(returnFields);
        }

        SearchRequestContext relationsContext = SearchRequestContext.builder(EntityIndexType.RELATION, relationDef.getRight().getName(), SecurityUtils.getCurrentUserStorageId())
            .query(SearchQuery.formQuery(FieldsGroup.and(fromEtalon, notFrom, notTo, relName, direct)))
            .count(1000)
            .scrollScan(true)
            .returnFields(searchFields)
            .build();

        SearchResultDTO result = searchService.search(relationsContext);
        if (CollectionUtils.isNotEmpty(result.getHits())) {
            Set<SearchResultProcessingElements> e = EnumSet.allOf(SearchResultProcessingElements.class);
            searchResultHitModifier.modifySearchResult(result, relationDef.getName(), StringUtils.EMPTY, e);
        }

        return result;
    }

//    @POST
//    @Path("/" + PATH_PARAM_SEARCH)
//    @Operation(
//        description = "Запросить табличную информацию по связям для набора периодов записей.",
//        method = HttpMethod.POST,
//        tags = { RELATIONS_TAGS },
//        requestBody = @RequestBody(content = @Content(schema = @Schema(implementation = RelationSearchRO.class)), description = "Поисковый запрос"),
//        responses = {
//            @ApiResponse(content = @Content(schema = @Schema(implementation = RestResponse.class)), responseCode = "200"),
//            @ApiResponse(content = @Content(schema = @Schema(implementation = DetailedErrorResponseRO.class)), responseCode = "500")
//        }
//    )
//    public Response getRelationInfo(RelationSearchRO request) {
//        Map<String, Map<String, List<RelationSearchCellRO>>> table = new HashMap<>();
//        // build data for all cell in table relName x record period
//        request.getRelationNames().forEach(relName -> {
//            Map<String, List<RelationSearchCellRO>> tableColumn = new HashMap<>();
//            request.getFromEtalonIds().forEach(recordTimelineRO -> {
//                RelationSearchCellRO cellRO = new RelationSearchCellRO();
//                cellRO.setRelName(relName);
//                cellRO.setEtalonId(recordTimelineRO.getEtalonId());
//                cellRO.setValidFrom(ConvertUtils.localDateTime2Date(recordTimelineRO.getValidFrom()));
//                cellRO.setValidTo(ConvertUtils.localDateTime2Date(recordTimelineRO.getValidTo()));
//                tableColumn.computeIfAbsent(recordTimelineRO.getEtalonId(), s -> new ArrayList<>()).add(cellRO);
//            });
//            table.put(relName, tableColumn);
//        });
//
//        FieldsGroup restrictions = FieldsGroup.and();
//        FieldsGroup idsFilter = FieldsGroup.or();
//        Date now = new Date();
//        for (RecordTimelineRO timeline : request.getFromEtalonIds()) {
//            idsFilter.add(FieldsGroup.and(
//                    FormField.exact(FIELD_FROM_ETALON_ID, timeline.getEtalonId()),
//                    FormField.range(FIELD_FROM, null, timeline.getValidTo()),
//                    FormField.range(FIELD_TO, timeline.getValidFrom(), null),
//                    FormField.exact(FIELD_DIRECTION_FROM, true)
//            ));
//        }
//        restrictions.add(idsFilter);
//        restrictions.add(FormField.exact(FIELD_RELATION_NAME, request.getRelationNames()));
//
//        SearchRequestContext relCtx = SearchRequestContext.builder(EntityIndexType.RELATION, request.getEntity(), SecurityUtils.getCurrentUserStorageId())
//                .query(SearchQuery.formQuery(restrictions))
//                .returnFields(
//                        FIELD_FROM.getName(),
//                        FIELD_TO.getName(),
//                        FIELD_RELATION_NAME.getName(),
//                        FIELD_FROM_ETALON_ID.getName(),
//                        FIELD_TO_ETALON_ID.getName())
//                .sorting(Collections.singletonList(SortField.of(FIELD_FROM, SortField.SortOrder.ASC)))
//                .page(0)
//                .count(SearchRequestContext.MAX_PAGE_SIZE)
//                .build();
//
//        SearchResultDTO relationResult = searchService.search(relCtx);
//        // fill information about relations in cell
//        for (SearchResultHitDTO hit : relationResult.getHits()) {
//            String relName = hit.getFieldFirstValue(FIELD_RELATION_NAME.getName());
//            String etalonIdFrom = hit.getFieldFirstValue(FIELD_FROM_ETALON_ID.getName());
//            Date relationFrom = SearchUtils.parse(hit.getFieldFirstValue(FIELD_FROM.getName()));
//            Date relationTo = SearchUtils.parse(hit.getFieldFirstValue(FIELD_TO.getName()));
//            // Always not empty list
//            List<RelationSearchCellRO> cellRoList = table.get(relName).get(etalonIdFrom);
//            for (RelationSearchCellRO cellRO : cellRoList) {
//                if ((cellRO.getValidFrom() == null || relationTo == null || !cellRO.getValidFrom().after(relationTo))
//                        && (cellRO.getValidTo() == null || relationFrom == null || !cellRO.getValidTo().before(relationFrom))) {
//
//                    cellRO.setTotal(cellRO.getTotal() + 1);
//                    if (cellRO.getData() == null) {
//                        cellRO.setData(new ArrayList<>());
//                    }
//                    if (cellRO.getData().size() < request.getMaxCount()) {
//                        RelationViewRO view = new RelationViewRO();
//                        view.setRelName(relName);
//                        view.setEtalonIdFrom(etalonIdFrom);
//                        view.setEtalonIdTo(hit.getFieldFirstValue(FIELD_TO_ETALON_ID.getName()));
//                        view.setValidFrom(SearchUtils.parse(hit.getFieldFirstValue(FIELD_FROM.getName())));
//                        view.setValidTo(SearchUtils.parse(hit.getFieldFirstValue(FIELD_TO.getName())));
//                        cellRO.getData().add(view);
//                    }
//                }
//            }
//        }
//        // remove empty cells
//        table.values().stream()
//                .flatMap(t -> t.values().stream())
//                .forEach(t -> t.removeIf(cellRo -> CollectionUtils.isEmpty(cellRo.getData())));
//
//        // calculate display names
//        for (Map.Entry<String, Map<String, List<RelationSearchCellRO>>> entry : table.entrySet()) {
//
//            List<Triple<String, Date, Date>> dataIds = new ArrayList<>();
//            entry.getValue().values().stream().flatMap(Collection::stream).forEach(row -> {
//                if (row.getData() != null) {
//                    for (RelationViewRO viewRO : row.getData()) {
//                        dataIds.add(Triple.of(viewRO.getEtalonIdTo(), viewRO.getValidFrom(), viewRO.getValidTo()));
//                    }
//                }
//            });
//
//            Map<String, List<Triple<String, Date, Date>>> displayNames = searchResultHitModifier.getDisplayNamesForRelationTo(entry.getKey(), dataIds);
//
//            entry.getValue().values().stream().flatMap(Collection::stream).forEach(row -> {
//                for (RelationViewRO viewRO : row.getData()) {
//                    viewRO.setEtalonDisplayNameTo(searchResultHitModifier.extractDisplayName(displayNames, viewRO.getEtalonIdTo(), viewRO.getValidFrom(), viewRO.getValidTo(), now));
//                }
//            });
//        }
//
//        RelationSearcResultRO result = new RelationSearcResultRO();
//        result.setMaxCount(request.getMaxCount());
//        result.setData(table.values().stream()
//                .flatMap(s -> s.values().stream())
//                .flatMap(Collection::stream)
//                .collect(Collectors.toList()));
//        return Response.ok(new RestResponse<>(result)).build();
//    }

    /**
     * Gets etalon relation objects along with their time lines by record etalon ID.
     *
     * @param relationName relation name
     * @param relationEtalonKey relation etalon ID
     * @param dateFor relation date
     * @param includeDraft include drafts version
     * @return relation timeline
     */
    @GET
    @Path("timeline/date/{" + RELATION_ETALON_ID_PARAM + "}")
    @Operation(description = "Gets etalon relation objects along with their time lines by record etalon ID.", responses = {
        @ApiResponse(content = @Content(schema = @Schema(implementation = DetailedErrorResponseRO.class)), responseCode = "400"),
        @ApiResponse(content = @Content(schema = @Schema(implementation = DetailedErrorResponseRO.class)), responseCode = "500"),
        @ApiResponse(content = @Content(schema = @Schema(implementation = GetRelationTimelineResultRO.class)), responseCode = "200")
    }, tags = RELATIONS_TAGS)
    public GetRelationTimelineResultRO relationTimelineForDate(
        @Parameter(description = "Relation etalon ID.", in = ParameterIn.PATH) @PathParam(RELATION_ETALON_ID_PARAM) String relationEtalonKey,
        @Parameter(description = "Relation date.", in = ParameterIn.QUERY) @QueryParam(DATE_FOR_PARAM) @DefaultValue("") String dateFor,
        @Parameter(description = "Relation draft id. Optional.", in = ParameterIn.QUERY) @QueryParam(RestConstants.QUERY_PARAM_DRAFT_ID) @DefaultValue("0") long draftId) {

        GetRelationTimelineRequestRO req = new GetRelationTimelineRequestRO();
        req.setRelationEtalonKey(relationEtalonKey);
        req.setDateFor(StringUtils.isBlank(dateFor) ? null : ConvertUtils.string2LocalDateTime(dateFor));
        req.setDraftId(draftId);

        return executeGetRelationsTimelineRequest(req);

    }

    @GET
    @Path("timeline/range/{" + RELATION_ETALON_ID_PARAM + "}")
    @Operation(description = "Gets etalon relation objects along with their time lines by record etalon ID.", responses = {
        @ApiResponse(content = @Content(schema = @Schema(implementation = DetailedErrorResponseRO.class)), responseCode = "400"),
        @ApiResponse(content = @Content(schema = @Schema(implementation = DetailedErrorResponseRO.class)), responseCode = "500"),
        @ApiResponse(content = @Content(schema = @Schema(implementation = GetRelationTimelineResultRO.class)), responseCode = "200"),
    }, tags = RELATIONS_TAGS)
    public GetRelationTimelineResultRO relationTimelineFromRange(
        @Parameter(description = "Relation etalon ID.", in = ParameterIn.PATH) @PathParam(RELATION_ETALON_ID_PARAM) String relationEtalonKey,
        @Parameter(description = "Period begin.", in = ParameterIn.QUERY) @QueryParam(VALID_FROM_PARAM) @DefaultValue("") String validFrom,
        @Parameter(description = "Period end.", in = ParameterIn.QUERY) @QueryParam(VALID_TO_PARAM) @DefaultValue("") String validTo,
        @Parameter(description = "Relation draft id. Optional.", in = ParameterIn.QUERY) @QueryParam(RestConstants.QUERY_PARAM_DRAFT_ID) @DefaultValue("0") long draftId) {

        GetRelationTimelineRequestRO req = new GetRelationTimelineRequestRO();
        req.setRelationEtalonKey(relationEtalonKey);
        req.setValidFrom(StringUtils.isBlank(validFrom) ? null : ConvertUtils.string2LocalDateTime(validFrom));
        req.setValidTo(StringUtils.isBlank(validTo) ? null : ConvertUtils.string2LocalDateTime(validTo));
        req.setDraftId(draftId);

        return executeGetRelationsTimelineRequest(req);
    }

    @GET
    @Path("timelines/date/{" + ETALON_ID_PARAM + "}")
    @Operation(description = "Gets etalon relation objects along with their time lines by record etalon ID.", responses = {
        @ApiResponse(content = @Content(schema = @Schema(implementation = DetailedErrorResponseRO.class)), responseCode = "400"),
        @ApiResponse(content = @Content(schema = @Schema(implementation = DetailedErrorResponseRO.class)), responseCode = "500"),
        @ApiResponse(content = @Content(schema = @Schema(implementation = GetRelationTimelinesResultRO.class)), responseCode = "200"),
    }, tags = RELATIONS_TAGS)
    public GetRelationTimelinesResultRO relationTimelinesForDate(
        @Parameter(description = "Etalon ID.", in = ParameterIn.PATH) @PathParam(ETALON_ID_PARAM) String etalonId,
        @Parameter(description = "Relation date.", in = ParameterIn.QUERY) @QueryParam(DATE_FOR_PARAM) @DefaultValue("") String dateFor,
        @Parameter(description = "Parent draft id. Optional.", in = ParameterIn.QUERY) @QueryParam(RestConstants.QUERY_PARAM_DRAFT_ID) @DefaultValue("0") long draftId,
        @Parameter(description = "Include inactive relations (false is default).", in = ParameterIn.QUERY) @QueryParam(RestConstants.DATA_PARAM_INCLUDE_INACTIVE) @DefaultValue("false") boolean includeInactive) {

        GetRelationTimelinesRequestRO req = new GetRelationTimelinesRequestRO();
        req.setEtalonId(etalonId);
        req.setDateFor(StringUtils.isBlank(dateFor) ? null : ConvertUtils.string2LocalDateTime(dateFor));
        req.setParentDraftId(draftId);
        req.setIncludeInactive(includeInactive);

        return executeGetRelationTimelinesRequest(req);
    }

    @GET
    @Path("timelines/range/{" + ETALON_ID_PARAM + "}")
    @Operation(description = "Gets etalon relation data object by relation etalon ID.", responses = {
        @ApiResponse(content = @Content(schema = @Schema(implementation = DetailedErrorResponseRO.class)), responseCode = "400"),
        @ApiResponse(content = @Content(schema = @Schema(implementation = DetailedErrorResponseRO.class)), responseCode = "500"),
        @ApiResponse(content = @Content(schema = @Schema(implementation = GetRelationTimelinesResultRO.class)), responseCode = "200"),
    }, tags = RELATIONS_TAGS)
    public GetRelationTimelinesResultRO relationTimelinesFromRange(
        @Parameter(description = "Etalon ID.", in = ParameterIn.PATH) @PathParam(ETALON_ID_PARAM) String etalonId,
        @Parameter(description = "Period begin.", in = ParameterIn.QUERY) @QueryParam(VALID_FROM_PARAM) @DefaultValue("") String validFrom,
        @Parameter(description = "Period end.", in = ParameterIn.QUERY) @QueryParam(VALID_TO_PARAM) @DefaultValue("") String validTo,
        @Parameter(description = "Parent draft id. Optional.", in = ParameterIn.QUERY) @QueryParam(RestConstants.QUERY_PARAM_DRAFT_ID) @DefaultValue("0") long draftId,
        @Parameter(description = "Include inactive relations (false is default).", in = ParameterIn.QUERY) @QueryParam(RestConstants.DATA_PARAM_INCLUDE_INACTIVE) @DefaultValue("false") boolean includeInactive) {

        GetRelationTimelinesRequestRO req = new GetRelationTimelinesRequestRO();
        req.setEtalonId(etalonId);
        req.setValidFrom(StringUtils.isBlank(validFrom) ? null : ConvertUtils.string2LocalDateTime(validFrom));
        req.setValidTo(StringUtils.isBlank(validTo) ? null : ConvertUtils.string2LocalDateTime(validTo));
        req.setParentDraftId(draftId);
        req.setIncludeInactive(includeInactive);

        return executeGetRelationTimelinesRequest(req);
    }

    @GET
    @Path("/{" + RELATION_ETALON_ID_PARAM + "}")
    @Operation(description = "Get relations content (either containment or relation to).", responses = {
        @ApiResponse(content = @Content(schema = @Schema(implementation = DetailedErrorResponseRO.class)), responseCode = "400"),
        @ApiResponse(content = @Content(schema = @Schema(implementation = DetailedErrorResponseRO.class)), responseCode = "500"),
        @ApiResponse(content = @Content(schema = @Schema(implementation = GetRelationResultRO.class)), responseCode = "200"),
    }, tags = RELATIONS_TAGS)
    public GetRelationResultRO getRelationByEtalonId(
        @Parameter(description = "Relation etalon ID.", in = ParameterIn.PATH) @PathParam(RELATION_ETALON_ID_PARAM) String relationEtalonKey,
        @Parameter(description = "Relation date.", in = ParameterIn.QUERY) @QueryParam(DATE_FOR_PARAM) @DefaultValue("") String dateFor,
        @Parameter(description = "Operation ID", in = ParameterIn.QUERY) @DefaultValue("") @QueryParam("") String operationId,
        @Parameter(description = "Relation draft id. Optional.", in = ParameterIn.QUERY) @QueryParam(RestConstants.QUERY_PARAM_DRAFT_ID) @DefaultValue("0") long draftId) {

        GetRelationRequestRO req = new GetRelationRequestRO();
        req.setRelationEtalonKey(relationEtalonKey);
        req.setForDate(StringUtils.isBlank(dateFor) ? null : ConvertUtils.string2LocalDateTime(dateFor));
        req.setOperationId(StringUtils.isBlank(operationId) ? null : operationId);
        req.setDraftId(draftId);

        return executeGetRelationRequest(req);
    }

    @POST
    @Operation(description = "Upsert relations", responses = {
        @ApiResponse(content = @Content(schema = @Schema(implementation = DetailedErrorResponseRO.class)), responseCode = "400"),
        @ApiResponse(content = @Content(schema = @Schema(implementation = DetailedErrorResponseRO.class)), responseCode = "500"),
        @ApiResponse(content = @Content(schema = @Schema(implementation = UpsertRelationsResultRO.class)), responseCode = "200")
    }, tags = RELATIONS_TAGS)
    public UpsertRelationsResultRO upsertRelations(UpsertRelationsRequestRO req) {
        return executeUpsertRelationsRequest(req);
    }

    /**
     * Inactivation of an etalon relation.
     *
     * @param relationEtalonKey etalon id
     * @return response
     */
    @DELETE
    @Path("/etalon/{" + RestConstants.DATA_PARAM_ID + "}")
    @Operation(
        description = "Inactivation of an etalon relation.",
        method = HttpMethod.DELETE,
        responses = {
            @ApiResponse(content = @Content(schema = @Schema(implementation = DetailedErrorResponseRO.class)), responseCode = "400"),
            @ApiResponse(content = @Content(schema = @Schema(implementation = DetailedErrorResponseRO.class)), responseCode = "500"),
            @ApiResponse(content = @Content(schema = @Schema(implementation = DeleteRelationResultRO.class)), responseCode = "200")
        }, tags = RELATIONS_TAGS
    )
    public DeleteRelationResultRO deactivateEtalonRelation(
        @Parameter(description = "Relation etalon ID", in = ParameterIn.PATH) @PathParam(RestConstants.DATA_PARAM_ID) String relationEtalonKey,
        @Parameter(description = "Relation draft id. Optional.", in = ParameterIn.QUERY) @QueryParam(RestConstants.QUERY_PARAM_DRAFT_ID) @DefaultValue("0") long draftId) {

        DeleteRelationRequestRO req = new DeleteRelationRequestRO();
        req.setEtalonId(relationEtalonKey);
        req.setDraftId(draftId);
        req.setInactivateEtalon(true);

        return executeDeleteRelations(req);
    }

    /**
     * Inactivation of an origin relation.
     *
     * @return response
     */
    @DELETE
    @Path("/period/{" + RestConstants.DATA_PARAM_ID + "}")
    @Operation(description = "Inactivation of an origin relation.", responses = {
        @ApiResponse(content = @Content(schema = @Schema(implementation = DetailedErrorResponseRO.class)), responseCode = "400"),
        @ApiResponse(content = @Content(schema = @Schema(implementation = DetailedErrorResponseRO.class)), responseCode = "500"),
        @ApiResponse(content = @Content(schema = @Schema(implementation = DeleteRelationResultRO.class)), responseCode = "200"),
    }, tags = RELATIONS_TAGS)
    public DeleteRelationResultRO deactivateVersionRelation(
        @Parameter(description = "Relation etalon ID", in = ParameterIn.PATH) @PathParam(RestConstants.DATA_PARAM_ID) String relationEtalonKey,
        @Parameter(description = "Begin periond", in = ParameterIn.QUERY) @QueryParam(VALID_FROM_PARAM) @DefaultValue("") String validFrom,
        @Parameter(description = "End period", in = ParameterIn.QUERY) @QueryParam(VALID_TO_PARAM) @DefaultValue("") String validTo,
        @Parameter(description = "Relation draft id. Optional.", in = ParameterIn.QUERY) @QueryParam(RestConstants.QUERY_PARAM_DRAFT_ID) @DefaultValue("0") long draftId) {

        DeleteRelationRequestRO req = new DeleteRelationRequestRO();
        req.setEtalonId(relationEtalonKey);
        req.setInactivatePeriod(true);
        req.setValidFrom(StringUtils.isBlank(validFrom) ? null : ConvertUtils.string2LocalDateTime(validFrom));
        req.setValidTo(StringUtils.isBlank(validTo) ? null : ConvertUtils.string2LocalDateTime(validTo));
        req.setDraftId(draftId);

        return executeDeleteRelations(req);
    }

    @POST
    @Path("/delete")
    @Operation(description = "Delete relations", responses = {
        @ApiResponse(content = @Content(schema = @Schema(implementation = DetailedErrorResponseRO.class)), responseCode = "400"),
        @ApiResponse(content = @Content(schema = @Schema(implementation = DetailedErrorResponseRO.class)), responseCode = "500"),
        @ApiResponse(content = @Content(schema = @Schema(implementation = DeleteRelationRequestRO.class)), responseCode = "200"),
    }, tags = RELATIONS_TAGS)
    public DeleteRelationResultRO deleteRelations(DeleteRelationRequestRO req) {
        return executeDeleteRelations(req);
    }

    // execs

    private GetRelationResultRO executeGetRelationRequest(GetRelationRequestRO req) {
        Objects.requireNonNull(req, "Get relation request can't be null");
        MeasurementPoint.init(MeasurementContextName.MEASURE_UI_RELATIONS_GET);
        MeasurementPoint.start();
        try {
            //getRelationByEtalonId
            GetRelationDTO relation = dataRelationsService.getRelation(GetRelationRequestContext.builder()
                .etalonKey(req.getEtalonId())
                .relationEtalonKey(req.getRelationEtalonKey())
                .operationId(req.getOperationId())
                .forDate(localDateTime2Date(req.getForDate()))
                .draftId(req.getDraftId())
                .build());
            GetRelationResultRO result = new GetRelationResultRO();
            result.setEtalonRelation(RelationToEtalonConverter.to(relation.getEtalon()));
            result.setRelationKeys(RelationKeysConverter.to(relation.getRelationKeys()));
            return result;
        } finally {
            MeasurementPoint.stop();
        }
    }

    private GetRelationTimelineResultRO executeGetRelationsTimelineRequest(GetRelationTimelineRequestRO req) {
        Objects.requireNonNull(req, "Get relation request can't be null");
        MeasurementPoint.init(MeasurementContextName.MEASURE_UI_GET);
        MeasurementPoint.start();
        try {
            LocalDateTime validFrom = req.getValidFrom();
            LocalDateTime validTo = req.getValidTo();
            Pair<Date, Date> range = (validFrom != null && validTo != null) ? Pair.of(localDateTime2Date(validFrom), localDateTime2Date(validTo)) : null;
            Timeline<OriginRelation> timeline = commonRelationsComponent.loadTimeline(GetRelationTimelineRequestContext.builder()
                .relationEtalonKey(req.getRelationEtalonKey())
                .forDate(localDateTime2Date(req.getDateFor()))
                .forDatesFrame(range)
                .draftId(req.getDraftId())
                .fetchData(false)
                .build());
            GetRelationTimelineResultRO result = new GetRelationTimelineResultRO();
            result.setTimeline(TimelineToTimelineROConverter.convert(timeline));
            return result;
        } finally {
            MeasurementPoint.stop();
        }
    }

    private GetRelationTimelinesResultRO executeGetRelationTimelinesRequest(GetRelationTimelinesRequestRO req) {
        Objects.requireNonNull(req, "Get relation request can't be null");
        MeasurementPoint.init(MeasurementContextName.MEASURE_UI_GET);
        MeasurementPoint.start();
        try {
            LocalDateTime validFrom = req.getValidFrom();
            LocalDateTime validTo = req.getValidTo();
            Pair<Date, Date> range = (validFrom != null && validTo != null) ? Pair.of(localDateTime2Date(validFrom), localDateTime2Date(validTo)) : null;
            Map<String, List<Timeline<OriginRelation>>> timelines = commonRelationsComponent.loadTimelines(GetRelationsTimelineRequestContext.builder()
                .etalonKey(req.getEtalonId())
                .relationNames(req.getRelationNames())
                .forDate(localDateTime2Date(req.getDateFor()))
                .forDatesFrame(range)
                .fetchData(false)
                .reduceReferences(true)
                .parentDraftId(req.getParentDraftId())
                .includeInactive(req.isIncludeInactive())
                .build());
            GetRelationTimelinesResultRO result = new GetRelationTimelinesResultRO();
            List<TimelineRO> converted = new ArrayList<>();
            TimelineToTimelineROConverter.convert(timelines.values().stream()
                .flatMap(Collection::stream).collect(Collectors.toList()), converted);
            result.setTimelines(converted);
            return result;
        } finally {
            MeasurementPoint.stop();
        }
    }

    private UpsertRelationsResultRO executeUpsertRelationsRequest(UpsertRelationsRequestRO req) {
        Objects.requireNonNull(req, "Upsert relation request can't be null");
        MeasurementPoint.init(MeasurementContextName.MEASURE_UI_RELATIONS_TO_UPSERT);
        MeasurementPoint.start();
        try {

            List<RecordRelationRO> relations = ObjectUtils.defaultIfNull(req.getTo(), Collections.emptyList());
            UpsertRelationsDTO upsertResult = dataRelationsService.upsertRelations(UpsertRelationsRequestContext.builder()
                .etalonKey(req.getEtalonId())
                .externalId(Objects.nonNull(req.getExternalId()) ? req.getExternalId().getExternalId() : null)
                .sourceSystem(Objects.nonNull(req.getExternalId()) ? req.getExternalId().getSourceSystem() : null)
                .entityName(req.getEntityName())
                .lsn(Objects.nonNull(req.getLsn()) ? req.getLsn().getLsn() : null)
                .shard(Objects.nonNull(req.getLsn()) ? req.getLsn().getShard() : null)
                .relationsFrom(relations.stream().map(it -> {

                    DataRecordRO to = Optional.ofNullable(it.getRecord()).orElseGet(DataRecordRO::new);
                    RecordExternalIdRO extId = Optional.ofNullable(to.getExternalId()).orElseGet(RecordExternalIdRO::new);
                    String relationName = it.getRelationName();
                    RelationElement el = StringUtils.isNotBlank(relationName) ? metaModelService.instance(Descriptors.DATA).getRelation(relationName) : null;
                    String entityByRelation = el != null ? el.getRight().getName() : null;
                    String entityName = StringUtils.isNotBlank(to.getEntityName()) ? to.getEntityName() : entityByRelation;

                    return UpsertRelationRequestContext.builder()
                        .parentDraftId(req.getDraftId())
                        .draftId(it.getDraftId())
                        .relationEtalonKey(it.getRelationEtalonKey())
                        .record(DataRecordEtalonConverter.from(to))
                        .relationName(relationName)
                        .sourceSystem(extId.getSourceSystem())
                        .externalId(extId.getExternalId())
                        .entityName(entityName)
                        .etalonKey(to.getEtalonId())
                        .validFrom(localDateTime2Date(to.getValidFrom()))
                        .validTo(localDateTime2Date(to.getValidTo()))
                        .build();

                }).collect(Collectors.groupingBy(UpsertRelationRequestContext::getRelationName))).build());
            UpsertRelationsResultRO result = new UpsertRelationsResultRO();
            final ResultDetailsRO details = new ResultDetailsRO();
            final List<EtalonRelationToRO> resultRelations = new ArrayList<>();
            Map<RelationStateDTO, List<UpsertRelationDTO>> rels = ObjectUtils.defaultIfNull(upsertResult.getRelations(), Collections.emptyMap());
            rels.values().stream().flatMap(List::stream).forEach(rel -> {
                EtalonRelationToRO etalonRelation = RelationToEtalonConverter.to(rel.getEtalon());
                etalonRelation.setDraftId(rel.getDraftId());
                if (etalonRelation != null) {
                    resultRelations.add(etalonRelation);
                }
                updateResultDetails(details, rel.getErrors());
            });
            result.setEtalonRelations(resultRelations);
            result.setDetails(details);
            return result;
        } finally {
            MeasurementPoint.stop();
        }
    }

    private DeleteRelationResultRO executeDeleteRelations(DeleteRelationRequestRO req) {
        Objects.requireNonNull(req, "Delete relation request can't be null");
        MeasurementPoint.init(MeasurementContextName.MEASURE_UI_RELATIONS_ETALON_DELETE);
        MeasurementPoint.start();
        try {
            DeleteRelationDTO deleteResult = dataRelationsService.deleteRelation(
                DeleteRelationRequestContext.builder()
                    .draftId(req.getDraftId())
                    .relationEtalonKey(req.getEtalonId())
                    .relationOriginKey(req.getOriginId())
                    .inactivateOrigin(req.isInactivateOrigin())
                    .inactivateEtalon(req.isInactivateEtalon())
                    .inactivatePeriod(req.isInactivatePeriod())
                    .validFrom(localDateTime2Date(req.getValidFrom()))
                    .validTo(localDateTime2Date(req.getValidTo()))
                    .build());
            DeleteRelationResultRO result = new DeleteRelationResultRO();
            result.setRelationKeys(RelationKeysConverter.to(deleteResult.getRelationKeys()));
            result.setDetails(errorsToDetails(deleteResult.getErrors()));
            return result;
        } finally {
            MeasurementPoint.stop();
        }
    }

    public DataRelationsService getDataRelationsService() {
        return dataRelationsService;
    }

    public void setDataRelationsService(DataRelationsService dataRelationsService) {
        this.dataRelationsService = dataRelationsService;
    }

    public CommonRelationsComponent getCommonRelationsComponent() {
        return commonRelationsComponent;
    }

    public void setCommonRelationsComponent(CommonRelationsComponent commonRelationsComponent) {
        this.commonRelationsComponent = commonRelationsComponent;
    }
}